Sidenote: While this is a Tiny Tight Guide to use JavaScript classes in SugarCube it might not explain everything. The goal of this Guide is to show how to create/define classes written in JavaScript and how those then can be used in SugarCube, it's not to teach programming or all of it's basics.
Okay so lets make a Character class.
First we need to define the class itself which we also attach-on/assign-to the window object because that's globally available.
We need to attach it to something (either window object or on the setup object) otherwise SugarCube would simply forget that we created it, once SugarCube is done running the JavaScript section, because that section is scoped and once a scope ends things that aren't global will simply disappear.
window.Character = class Character {};
A class is like a blueprint to a house or a car, it describes the object that eventually gets build using the blueprint as a template/guidance. So a class describes an object, and the object also commonly referred to as "instance" is the finished build house/car that can be used and modified.
Every object has a constructor
, even if you don't define it it will just have a default one.
A constructor
is a special method, that gets called when ever the new
keyword is invoked along with a class.
a method is a function that belongs to a class/instance, you might have come across <string>.toLowerCase()
or <array>.random()
in SugarCube, those are methods which simply are functions running based on the instances current state.
The constructor
in SugarCube is used for 2 things, to provide default values to the resulting instance, and to be able to overwrite those default values by passing in an object.
SugarCube does use the constructor
to pass in an object that holds the saved data when loading a save file.
window.Character = class Character {
constructor(config = null) {
}
};
Is how we create a constructor
that accepts an argument, the argument being called config
which will contain the data we want to overwrite.
we do give it a default value of null
tho, because if we don't and if we don't pass in a value into the constructor
then it will be undefined
, so it's better to tell it that there is no value.
Lets first create the properties name
and money
and give them some placeholder value:
window.Character = class Character {
constructor(config = null) {
this.name = "NA";
this.money = 0;
}
};
The this
keyword is a self reference, it refers to itself and can be used to run methods or access properties, similar how you can refer to your self while providing information about yourself "i have blue eyes" or "my eye color" is simply this.eyeColor
.
so in the example above, we technically attach a name
keyword to the self reference this
and giving it a default value which is an empty string.
Do note that the spelling is always case sensitive and must be consistent throughout.
Now if we want SugarCube to restore values, or want to be able to pass in values ourselves to overwrite the default value we created we can simply copy and paste this code at the end of the constructor
:
window.Character = class Character {
constructor(config = null) {
this.name = "NA";
this.money = 0;
if(config != null) {
Object.keys(config).forEach(function(pn) {
this[pn] = clone(config[pn]);
}, this);
}
}
};
This part in particular:
if(config != null) {
Object.keys(config).forEach(function(pn) {
this[pn] = clone(config[pn]);
}, this);
}
This does check if config
has a value, and if so will overwrite all default values that we defined above via the this.property = value;
with the properties that are defined within that config
object. (note: pn
is simply short for propertyname and is a temporary variable, the name does not matter and you can rename it if you like as long as you rename all occurences within the .forEach()
method)
Now SugarCube does require non-generic objects (which a custom class is) to also define 2 methods, the .toJSON()
and .clone()
methods.
Methods in JavaScript follow the signature of the method name, then the argument list inside parentheses, followed by the code block which are indicated by the area between the squiggly brackets:
methodname(args){
/* code block*/
}
The args can be left empty if you don't intend it to receive values from the "outside" eg.:
methodname(){
/* code block*/
}
We technically already created a method on our class, the constructor
being a special method that runs when ever we create a new instance of a class.
However the .toJSON
and .clone
methods are probably code snippets that you'll simply copy and paste between classes.
Here's what i usually use:
window.Character = class Character {
constructor(config = null) {
this.name = "NA";
this.money = 0;
if(config != null) {
Object.keys(config).forEach(function(pn) {
this[pn] = clone(config[pn]);
}, this);
}
}
clone() {
return new this.constructor(this);
}
toJSON() {
const ownData = {};
Object.keys(this).forEach(function(pn) {
ownData[pn] = clone(this[pn]);
}, this);
return JSON.reviveWrapper(`new ${this.constructor.name}($ReviveData$)`, ownData);
}
};
To at least somewhat cover those methods, starting with the easy one
what the clone method does is using the new
keyword while using the self reference to fetch its own constructor
and then passing in itself.
this has the affect that all values are copied into a new object/instance and then returned back "outside" using the return
keyword.
Another way to write this would be: Or:
clone() { return new Character(this); }
clone() { return JSON.parse(JSON.stringify(this)); }
Altho the first variant does not offer much flexibility and needs to be renamed for each and every new class you copy this snippet into, while the second one is a little more heavy on performance because it first needs to turn it into a string value then back into an object which when turned into a string strips all functions off of it.
All of which are quite strong downsides that are easily prevented by doing this instead:
clone() {
return new this.constructor(this);
}
Another benefit of writing it like this, is that it also can be inherited by child classes (but we aren't covering inheritance just yet)
The other toJSON
method is first creating a new variable called ownData
which will gather a copy (not a reference) to the objects own data, which it does in a loop for all property names and simply cloning it, then once it has all values it uses SugarCube's extension method from the JSON
class to create a reviveWapper which is needed for saving and loading the object.
basically it creates a command how to create the current object.
Before we continue with more class related stuff, I'm gonna explain how we create a new instance of a class, inside a passage we can simply write:
<<set $player = new Character({name: "Maxine"})>>
$player.name
Money: $player.money
The new Character()
creates a new instance of the object, the {name: "Maxine"}
is how we overwrite the default value of the name
property of our object, basically we passed in what value it should be.
last part $player.name
is SugarCube's naked variable markup and simply prints it onto the passage. If everything worked out it should simply say "Maxine" if we don't provide a name property overwrite it should say "NA" since that's the default value, just like the money part should be displayed as Money: 0
since we aren't overwriting it.
Now back on the topic of methods, there are also getter and setter methods that we can define, those are special methods which on the "outside" don't require the parentheses but rather act like you're directly assigning to a property directly, like we did with $player.name
for example in the above class we have the money property that we can directly access via $player.money
and we could change the value just like so <<set $player.money -= 9999999999>>
which will set the property money
of the $player
variable to 0 - 9999999999
which is -9999999999
but lets say we don't want to allow it to go past 0
because you cannot take negative money with you (you sure can owe people money, but they cannot take money away from you that you don't have on hand)
to do this we change the money property in our class to _money
and add a getter and setter method with the original money
name so we can still use it like we did prior:
window.Character = class Character {
constructor(config = null) {
this.name = "NA";
this._money = 0;
if(config != null) {
Object.keys(config).forEach(function(pn) {
this[pn] = clone(config[pn]);
}, this);
}
}
get money() {
return this._money;
}
set money(value) {
this._money = value;
if(this._money < 0)
this._money = 0;
}
clone() {
return new this.constructor(this);
}
toJSON() {
const ownData = {};
Object.keys(this).forEach(function(pn) {
ownData[pn] = clone(this[pn]);
}, this);
return JSON.reviveWrapper(`new ${this.constructor.name}($ReviveData$)`, ownData);
}
};
To mark a getter method we just need to prefix it with the keyword get
and to mark something as a setter method we only need to prefix it with the keyword set
as seen above.
now if we were to try <<set $player.money -= 9999999999>>
we'd only end up setting $player.money
to 0
but if we tried <<set $player.money += 50>>
then it would add 50 to whatever $player.money
currently is (well technically we are modifying $player._money
but we are using the getter and setter methods $player.money
to act as a property while it allows us to control what happens when we set/get values to/from it)
I guess last thing to cover, since I mentioned it above, is inheritance.
inheritance is meant to expand upon a class with new behavior.
a "child" class inherits all public/protected
properties and methods of a parent class that it extends
.
public
, protected
, and private
are the 3 common accessibility levels.
to explain it, the $player.name
property is marked public and can be accessed anywhere and be inherited to any class that derives from the Character
class, it's how we're able to display the current value from a passage context or change it without needing to trigger another class method.
private
on the other hand is everything that can only be accessed within the classes/objects/instances own scope, it's not available to the outside but only the classes inner workings.
like the steering wheel of a car is only accessible inside the car, where the car's door is accessible from the outside and inside of the car. But private
marked properties/methods are only available inside the class that it was defined in.
lastly protected
is more kin to private
with the only difference that properties and methods are available to all inherited
classes, they still aren't accessible to the "outside" but child classes can inherit and use them as if they are defined on them.
In JavaScript and by extension SugarCube there isn't really a protected
keyword, the most common way to deal with it is to fake it like we did with this._money
the _money
property isn't meant to be exposed to the outside, for that we have defined the getter and setter methods, while technically it's public and just as manipulable as the name
property but this is the best what JavaScript seems to have without turning it into a private field (field being a term to describe private properties).
To mark a property of method private
in JavaScript/SugarCube, we simply prefix the name with a #
for example this.#name
or #AddAMillionMoney(){this.money += 1000000;}
just keep in mind that private fields cannot be accessed from a Passage, nor can it be passed down to child classes.
Everything is by default public
so there is no prefix or tricks there.
Now lets say we want to extend the Character
class with a dedicated Player
so we can add specific things to the player object that is unique to the player instance for example if our story has some fighting mechanics but not all NPCs are fight-able so we want health to be specific to the Player
class, for this we add a new class that extends on our Character
class like so:
window.Player = class Player extends Character {
constructor(config = null) {
super();
this.health = 20;
this.maxHealth = 20;
if(config != null) {
Object.keys(config).forEach(function(pn) {
this[pn] = clone(config[pn]);
}, this);
}
}
};
Now to extend a class in JavaScript it's as simple as writing class MyChildClassName extends MyParentClassName
but we do need a constructor
, and we do need to call super()
which is a special method call that calls the Parent classes constructor
, so it creates the properties (and maybe methods) of the parent class first before constructing the child class, we could pass in the config
object into the super()
method call, but since we still defining new properties after the parents constructor
call we don't need to overwrite any default values yet, instead we do it at the end of our new child classes constructor
, which might seem repetitive but we also don't want to pass down the object to the parent constructor
to create the health property to then have the child constructor
overwrite it to the defaults, plus it does get skipped if we leave super()
empty.
To re-explain super()
in different words, what it does is:
the child class Player
calls super()
which in return the the constructor()
method of parent class which is Character
and can be used to pass in values to the parent constructor
eg.: super({name: "Maxine"});
or using the variable super(config);
but for our case we don't need to do that and values would be overwritten by the most specific child class, in this case the Player
class.
The reason why we don't include the .toJSON
and .clone
methods in our Player
class is because they are generically written enough that when they are inherited from the Character
class it still works for our Player
child class as long as we provide the class name (anonymous classes, meaning classes without a name for example Character/Player, are possible in JavaScript but i won't touch on those, because all that is to them is literally just skipping the name part when writing the class like this windows.MyClass = class {};
and that doesn't fill the this.constructor.name
field which breaks our inheritance of the parent class. So generally speaking they aren't useful for for inheritance unless we overwrite the .toJSON
method).
Now we also need to change our passage again from this: | To this: |
|
|